Micron Document
rns.moscow 🟥 [git]

Commit 07db24c73767fd670e5079fbfa6f2c4e7cfb298a


Parents : ed7abdd
Author : Nickie Deuxyeux <nikolay@dvoeglazov.ru>
Date : 2026-06-26T13:30:56+03:00

Add bidirectional probe mode to wardrive bot

New wardrive.probe destination accepts 36-byte packets carrying GPS
position + N+1 downlink metrics from the app. Bot records uplink RF
stats, stores both directions, then replies with a 12-byte packet
containing its measured uplink RSSI/SNR/Q to the sender's
wardrive.probe_reply destination. DB migrated to add dl_rssi/dl_snr/dl_q
columns. probe destination is announced on the regular interval.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Changes

1 files changed, 105 insertions(+), 8 deletions(-)


Diff

diff --git a/bot/wardrive_bot.py b/bot/wardrive_bot.py
index 1975c82..b7807cc 100755
--- a/bot/wardrive_bot.py
+++ b/bot/wardrive_bot.py
@@ -46,6 +46,15 @@ RELAY_PAYLOAD_FMT = ">ffifi16s"
RELAY_PAYLOAD_SIZE = struct.calcsize(RELAY_PAYLOAD_FMT)
_INT_NULL = 2147483647 # sentinel for nullable int32 fields
+# Probe mode: 36-byte payload — lat + lon + sender hash + previous downlink metrics (N+1 lag)
+# dl_rssi/_q use _INT_NULL sentinel when absent; dl_snr uses NaN.
+PROBE_PAYLOAD_FMT = ">ff16sifi"
+PROBE_PAYLOAD_SIZE = struct.calcsize(PROBE_PAYLOAD_FMT)
+
+# Probe reply: 12-byte uplink metrics sent back to the app
+PROBE_REPLY_FMT = ">ifi"
+PROBE_REPLY_SIZE = struct.calcsize(PROBE_REPLY_FMT)
+
BOT_CONFIG_PATH = "/etc/reticulum/wardrive_bot.json"
def _load_bot_config():
@@ -123,27 +132,34 @@ def init_db(path):
rssi INTEGER,
snr REAL,
q INTEGER,
- source TEXT
+ source TEXT,
+ dl_rssi INTEGER,
+ dl_snr REAL,
+ dl_q INTEGER
)
""")
- # migrate existing DBs that predate the source column
cols = [r[1] for r in con.execute("PRAGMA table_info(points)").fetchall()]
- if "source" not in cols:
- con.execute("ALTER TABLE points ADD COLUMN source TEXT")
+ for col, typedef in [("source", "TEXT"), ("dl_rssi", "INTEGER"), ("dl_snr", "REAL"), ("dl_q", "INTEGER")]:
+ if col not in cols:
+ con.execute(f"ALTER TABLE points ADD COLUMN {col} {typedef}")
con.execute("CREATE INDEX IF NOT EXISTS points_ts ON points(ts)")
con.commit()
return con
-def insert_point(con, receiver_hash, sender_hash, lat, lon, rssi, snr, q, source):
+def insert_point(con, receiver_hash, sender_hash, lat, lon, rssi, snr, q, source,
+ dl_rssi=None, dl_snr=None, dl_q=None):
con.execute(
- "INSERT INTO points(ts, receiver_hash, sender_hash, lat, lon, rssi, snr, q, source) "
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO points(ts, receiver_hash, sender_hash, lat, lon, rssi, snr, q, source, dl_rssi, dl_snr, dl_q) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(int(time.time()), receiver_hash, sender_hash,
round(lat * 1_000_000), round(lon * 1_000_000),
round(rssi) if rssi is not None else None,
snr,
round(q) if q is not None else None,
- source),
+ source,
+ round(dl_rssi) if dl_rssi is not None else None,
+ dl_snr,
+ round(dl_q) if dl_q is not None else None),
)
con.commit()
@@ -389,6 +405,75 @@ def handle_collector_packet(reticulum, db, receiver_hash):
return callback
+# ---------------------------------------------------------------------------
+# Probe packet handler (wardrive.probe) — bidirectional mode
+# ---------------------------------------------------------------------------
+
+def handle_probe_packet(reticulum, db, receiver_hash):
+ def callback(message, packet):
+ rssi = reticulum.get_packet_rssi(packet.packet_hash)
+ snr = reticulum.get_packet_snr(packet.packet_hash)
+ q = reticulum.get_packet_q(packet.packet_hash)
+
+ if rssi is None and snr is None:
+ log.warning("Dropped probe packet: no RF stats (not LoRa)")
+ return
+
+ if len(message) != PROBE_PAYLOAD_SIZE:
+ log.warning(f"Dropped probe packet: bad size ({len(message)}B, expected {PROBE_PAYLOAD_SIZE}B)")
+ return
+
+ try:
+ lat, lon, sender_hash, dl_rssi_val, dl_snr_val, dl_q_val = struct.unpack(PROBE_PAYLOAD_FMT, message)
+ except struct.error as e:
+ log.warning(f"Dropped probe packet: unpack failed — {e}")
+ return
+
+ if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
+ log.warning(f"Dropped probe packet: invalid coordinates ({lat}, {lon})")
+ return
+
+ dl_rssi = None if dl_rssi_val == _INT_NULL else dl_rssi_val
+ dl_snr = None if math.isnan(dl_snr_val) else float(dl_snr_val)
+ dl_q = None if dl_q_val == _INT_NULL else dl_q_val
+
+ parts = []
+ if rssi is not None: parts.append(f"RSSI: {rssi} dBm")
+ if snr is not None: parts.append(f"SNR: {snr} dB")
+ if q is not None: parts.append(f"Q: {q}")
+ if dl_rssi is not None: parts.append(f"DL-RSSI: {dl_rssi} dBm")
+ if dl_snr is not None: parts.append(f"DL-SNR: {dl_snr} dB")
+ rf_stats = ", ".join(parts)
+
+ source_hex = RNS.prettyhexrep(sender_hash)
+ log.info(f"PRB src={source_hex} ({lat:.5f}, {lon:.5f}) {rf_stats}")
+ insert_point(db, receiver_hash, sender_hash, lat, lon, rssi, snr, q, "probe",
+ dl_rssi=dl_rssi, dl_snr=dl_snr, dl_q=dl_q)
+ relay_point(lat, lon, rssi, snr, q, sender_hash)
+
+ # Reply with bot-side uplink metrics to sender's wardrive.probe_reply destination
+ try:
+ sender_identity = RNS.Identity.recall(sender_hash)
+ if sender_identity is None:
+ log.warning(f"Probe reply: unknown identity for {source_hex}, skipping reply")
+ return
+ reply_dest = RNS.Destination(
+ sender_identity,
+ RNS.Destination.OUT,
+ RNS.Destination.SINGLE,
+ "wardrive", "probe_reply",
+ )
+ rssi_val = round(rssi) if rssi is not None else _INT_NULL
+ snr_val = float(snr) if snr is not None else float("nan")
+ q_val = round(q) if q is not None else _INT_NULL
+ reply_payload = struct.pack(PROBE_REPLY_FMT, rssi_val, snr_val, q_val)
+ RNS.Packet(reply_dest, reply_payload).send()
+ log.info(f"PRB reply → {source_hex}")
+ except Exception as e:
+ log.warning(f"Probe reply failed: {e}")
+
+ return callback
+
# ---------------------------------------------------------------------------
# Relay packet handler (wardrive.aggregator)
# ---------------------------------------------------------------------------
@@ -457,6 +542,16 @@ def main():
handle_collector_packet(reticulum, db, bytes(lxmf_dest.hash))
)
+ probe_dest = RNS.Destination(
+ identity,
+ RNS.Destination.IN,
+ RNS.Destination.SINGLE,
+ "wardrive", "probe",
+ )
+ probe_dest.set_packet_callback(
+ handle_probe_packet(reticulum, db, bytes(lxmf_dest.hash))
+ )
+
if not RELAY_HASH:
aggregator_dest = RNS.Destination(
identity,
@@ -476,6 +571,7 @@ def main():
log.info(f"Name: \"{DISPLAY_NAME}\"")
log.info(f"LXMF Address : {RNS.prettyhexrep(lxmf_dest.hash)}")
log.info(f"Collector Destination: {RNS.prettyhexrep(collector_dest.hash)}")
+ log.info(f"Probe Destination : {RNS.prettyhexrep(probe_dest.hash)}")
if aggregator_dest:
log.info(f"Aggregator Destination: {RNS.prettyhexrep(aggregator_dest.hash)}")
@@ -485,6 +581,7 @@ def main():
if now - last_announce >= ANNOUNCE_INTERVAL:
lxmf_dest.announce()
collector_dest.announce()
+ probe_dest.announce()
if aggregator_dest:
aggregator_dest.announce()
last_announce = now

Served by rngit 1.4.2 - Generated in 0.02s